iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png
現在戲劇流行穿越劇,在FP的世界裏也可以進行型別容器的穿越,也就是改變容器嵌套的次序,擁有這種特性的型別容器我們稱之為Traversable。當遇到容器型別互相嵌套,除了透過自然轉換的技巧,也可以透過調整容器型的次序來做簡化的工作。

Traversable的型別容器必須具備sequence和traverse兩個函數的Functor。

Sequence

我們先看sequence的Hindley-Milner型別簽名

sequence :: (Applicative f, Traversable t) => t (f a) -> f (t a)

很明顯的 f 必須是一個Applicative Functor(必須實作map、ap和of三個函數),而 t 必須是一個Traversalbe(也就是有實作sequence和traverse),參數是一個Functor在內,Traversable在外的容器嵌套型別,而輸出的結果是Functor在外,Traversable在內的容器嵌套型別。

由於typescript型別支援並沒有支援HKT,所以sequence需要加一個Applicative型別的參數,我們先看以下的例子:

import * as O from 'fp-ts/Option';
import * as E from 'fp-ts/Either';
const someValue = O.some(5);
const rightValue = E.right(3);
const eitherSomeValue: E.Either<never, O.Option<number>> = E.right(someValue);

const someRightValue: O.Option<E.Either<never, number>> = O.some(rightValue)
const result1: O.Option<E.Either<never, number>> = E.sequence(O.Applicative)(
  eitherSomeValue
);
const result2: E.Either<never, O.Option<number>> = O.sequence(E.Applicative)(
  someRightValue
);

注意我們的型別註解,經過sequence函數處理後的型別,嵌套的順序已經交換了。使用時要用外部容器的sequence,套用內部容器的Applicative。

其實我們最常使用的Traversable容器是Array,Array與不同的型別容器進行穿越會產生不同的效果,請仔細檢視這些不同的效果:

模組匯入

import { pipe } from 'fp-ts/function';
import * as E from 'fp-ts/Either';
import * as R from 'fp-ts/Reader';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as A from 'fp-ts/Array';

Array穿越Array

const arraySequenceArray = A.sequence(A.Applicative)([
  [1, 2],
  [3, 4],
]);
// 陣列穿越陣列會和ap有同樣的情形,第一陣列的每個元素會對第二陣列的每個元素都進行穿越,所以會變成4個子陣列的陣列
console.log(arraySequenceArray); // [ [ 1, 3 ], [ 1, 4 ], [ 2, 3 ], [ 2, 4 ] ]

const arraySequenceEmptyArray = A.sequence(A.Applicative)([[], [1, 2]]);
console.log(arraySequenceEmptyArray); // []

Array穿越Option
陣列穿越Option時,只要陣列列有一個none,結果會只有一個none。

const arraySequenceSome = A.sequence(O.Applicative)([
  O.some(1),
  O.some(2),
  O.some(3),
]);
console.log(arraySequenceSome); // O.some([1, 2, 3])

const arraySequenceNone = A.sequence(O.Applicative)([
  O.some(1), // 沒有效果
  O.none,
  O.some(3), // 沒有效果
]);
console.log(arraySequenceNone); // O.none

陣列穿越Either時,只要陣列列有一個left,結果會只會保留第一個left。
Array穿越Either

const arraySequenceRight = A.sequence(E.Applicative)([
  E.right(1),
  E.right(2),
  E.right(3),
]);
console.log(arraySequenceRight); // E.right([1, 2, 3])

const arraySequenceLeft = A.sequence(E.Applicative)([
  E.right(1), // 沒有效果
  E.left(2),
  E.left(3), // 沒有效果
]);
console.log(arraySequenceLeft); // E.left(2)

Array穿越Reader
Reader其實就是函數,因此陣列穿越Reader會把陣列中的函數變成一個輸出為陣列的函數。

type numberFunction = (x: number) => number;
const addOne: numberFunction = (x) => x + 1;
const addTwo: numberFunction = (x) => x + 2;
const addThree: numberFunction = (x) => x + 3;

const arraySequenceReader: R.Reader<number, Array<number>> = A.sequence(
  R.Applicative
)([addOne, addTwo, addThree]);
console.log(arraySequenceReader(5)); // [6, 7, 8]

因此各個型別容器大多有一個sequenceArray的函數可供Array穿越使用,使用這個函數時不需要指定Applicative型別而減少一個參數。

const arrayOption: Array<O.Option<number>> = [O.some(1), O.some(2), O.some(3)];

const optionArray = O.sequenceArray(arrayOption); // O.of([1, 2, 3])

Traverse

考慮下面的例子:

const numberArray = [1, 2, 3, 4];
const numberToOption = (x: number) => (x > 0 ? O.some(x) : O.none);
const ArrayMapAndSequenceOption = pipe(
  numberArray,
  A.map(numberToOption),
); // [O.some(1), O.some(2), O.some(3), O.some(4)]

[O.some(1), O.some(2), O.some(3), O.some(4)]不是我們想要的結果,我們希望能得到O.some([1, 2, 3, 4]),因此必須再將管道pipe中最後再加上A.sequence(O.Applicative),如此便可得到我們想要的結果O.some([1, 2, 3, 4])。A.map之後會馬上進行A.sequence可以簡化為單一函數A.traverse代替,因此以下四個函數都得到相同的結果:

// 以下四個函數的輸出都是`O.some([1, 2, 3, 4])`
const ArrayMapAndSequenceOption = pipe(
  numberArray,
  A.map(numberToOption),
  A.sequence(O.Applicative)
);

const mapAndSequenceOption = pipe(
  numberArray,
  A.map(numberToOption),
  O.sequenceArray
);

const arrayTraverseOption = pipe(
  numberArray,
  A.traverse(O.Applicative)(numberToOption)
);

const traverseOption = pipe(numberArray, O.traverseArray(numberToOption));

對任意一個Traversable T和一個Applicative Functor F,現在有一個函數 f :: A -> F<A>和一筆T<A>的資料,此時T.map(f)是一個T<A> -> T<F<A>>的函數,因此在在pipe的後面馬上應用T.sequence(F.Applicative)可以得到F<T<A>>型別的答案。更簡便的方法是直接執行T.traverse(F.Applicative)(T<A>)便可以得到相同的效果。

非同步穿越

非同Task和TaskEither都提供了二個Applicative的實例,分別是ApplicativePar和ApplicativeSeq來配合sequeceArray和traverseArray交換陣列和非同步的嵌套順序,實質上的意義是讓多個非同步的操作簡化為一個非同步操作,如果採用ApplicativePar實例,非同步工作會平行執行;如果採用ApplicativeSeq實例,非同步工作會依序執行,下面的例子可以清楚的驗證它們的差異。

type Delay = (ms: number) => T.Task<void>;
const delay: Delay = (ms) => async () =>
  new Promise((resolve) => setTimeout(resolve, ms));

const taskNumber = (t: number) => (x: number) => async () => {
  await delay(t)();
  return x;
};

const arraySequenceTaskPar = pipe(
  numberArray,
  A.map((n) => taskNumber(n * 2000)(n)),
  A.sequence(T.ApplicativePar),
  T.map(
    A.map((x) => {
      console.log('parallelly...', x);
    })
  )
); // 因為平行處理,所以需要max(2, 4, 6) = 6秒可以完成所有工作

const arraySequenceTaskSeq = pipe(
  numberArray,
  A.map((n) => taskNumber(n * 1000)(n)),
  A.sequence(T.ApplicativeSeq),
  T.map(
    A.map((x) => {
      console.log('sequentially...', x);
    })
  )
); // 因為依序處理,所以需要1 + 2 + 3 = 6秒可以完成所有工作

arraySequenceTaskSeq();
arraySequenceTaskPar();

執行結果:

parallelly... 3

parallelly... 2

parallelly... 1

sequentially... 3

sequentially... 2

sequentially... 1

雖然都是需要6秒鐘,實際執行平行處理仍然比依序處理略快一些。

sequenceS And sequenceT

在Apply模組中也有兩個和sequence有關的函數-sequenceS And sequenceT可以分別對物件和元組進行穿越,這兩個函數的第一個參數一樣是一個Applicative實例,sequenceS的第二個參數是物件,而物件各個屬性質必須是Applicative Functor型別,回傳值則是物件;而sequenceT的第二個參數是一系列的Applicative Functor型別,回值值是元組(Tuple),我們一樣用非同步的TaskEither來說明:

type User = {
  id: number;
  profile: {
    name: string;
    email: string;
  };
};
type Preference = { theme: string };
type Order = string;
type Data = {
  user: User;
  preference: Preference;
  orders: Order[];
};

const fetchUser =
  (ms: number) =>
  (id: number): TE.TaskEither<Error, User> =>
    TE.tryCatch(
      async () => {
        delay(ms)();
        return {
          id,
          profile: { name: 'John', email: 'john@example.com' },
        };
      },
      (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
    );

const fetchPreference =
  (ms: number) =>
  (id: number): TE.TaskEither<Error, Preference> =>
    TE.tryCatch(
      async () => {
        delay(ms)();
        return {
          theme: 'dark',
        };
      },
      (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
    );

const fetchOrders =
  (ms: number) =>
  (id: number): TE.TaskEither<Error, Order[]> =>
    TE.tryCatch(
      async () => {
        delay(ms)();
        return ['order1', 'order2'];
      },
      (reason) => (reason instanceof Error ? reason : new Error(String(reason)))
    );

const getUserDataSequential = (userId: number) =>
  pipe(
    sequenceS(TE.ApplicativeSeq)({
      user: fetchUser(2000)(userId),
      preference: fetchPreference(3000)(userId),
      orders: fetchOrders(1000)(userId),
    }), // 原來是三個TE.TaskEither穿越後只剩一個TE.TaskEither
    TE.match(
      (error) => error.message,
      ({ user, preference, orders }) =>
        `${user.profile.name}'s preference is ${
          preference.theme
        } with orders ${orders.join(',')}\n`
    )
  );

const getUserDataParallel = (userId: number) =>
  pipe(
    sequenceS(TE.ApplicativePar)({
      user: fetchUser(2000)(userId),
      preference: fetchPreference(3000)(userId),
      orders: fetchOrders(1000)(userId),
    }),
    TE.match(
      (error) => error.message,
      ({ user, preference, orders }) =>
        `${user.profile.name}'s preference is ${
          preference.theme
        } with orders ${orders.join(',')}\n`
    )
  );

// Using sequenceT with parallel execution
const getUserDataParallelT = (userId: number) =>
  pipe(
    sequenceT(TE.ApplicativePar)(
      fetchUser(2000)(userId),
      fetchPreference(3000)(userId),
      fetchOrders(1000)(userId)
    ),
    TE.map(([user, prefs, orders]) => ({
      user,
      preference: prefs,
      orders,
    })),
    TE.match(
      (error) => error.message,
      ({ user, preference, orders }) =>
        `${user.profile.name}'s preference is ${
          preference.theme
        } with orders ${orders.join(',')}\n`
    )
  );

// Example usage
const main = async () => {
  // Parallel execution
  const resultPar = await getUserDataParallel(1)();
  console.log('Parallel result:', resultPar);

  // Sequential execution
  const resultSeq = await getUserDataSequential(1)();
  console.log('Sequential result:', resultSeq);

  // Parallel execution with tuple
  const resultParT = await getUserDataParallelT(1)();
  console.log('Parallel result with tuple:', resultParT);
};
main();

執行結果:

Parallel result: John's preference is dark with orders order1,order2

Sequential result: John's preference is dark with orders order1,order2

Parallel result with tuple: John's preference is dark with orders order1,order2

今日小結

fp-ts中大部分的模組都實作了sequence和traverse,也都有Applicative的型別實例(instance),因此彼此互相都可以透過這兩個函數進行穿越來交換型別容器的嵌套順序。sequence和traverse需要給予一個內層嵌套容器的Applicative實例作為第一個參數

而實務上最常應用sequence和traverse的模組為Array,而其它模組大多提供sequenceArray和traverseArray的函數可以代替Array模組中的sequence和traverse函數,由於sequenceArray和traverseArray都已經知道要穿越的內層嵌套容器,因此不需要Applicative實例作為第一個參數。

使用sequeceArray、Array中的sequence、traverseArray或Array中的traverse穿越其它型別容器時要注意和掌握各個不同的效果。

今天分享的內容就到這邊,明天再見。


上一篇
Day 22. ADT-Algebraic Data Type
系列文
數學老師學函數式程式設計 - 以fp-ts啟航23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言